Skip to content

Add auto text positioning for scatter traces - #8065

Open
chriddyp wants to merge 42 commits into
mainfrom
claude/youthful-shannon-s2pabd
Open

chriddyp wants to merge 42 commits into
mainfrom
claude/youthful-shannon-s2pabd

Conversation

@chriddyp

@chriddyp chriddyp commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Fixed #4674

Overview written in my own words

I was making the graph below and running into the classic text overlap issues. This PR adds textposition: 'auto' to avoid text overlap.

Before:
image

After:
image

Intuitively, we try to find text position that:

  1. Is one of the 8 possible directions (top, top-right, right, etc).
  2. Prefers a direction that is "away" from points that are nearby (to give space for those points' text positions). This is the "repulsion vector".
  3. If there aren't any nearby points, then defaults to top
  4. If there isn't any room because of overlap, then it'll try to find a spot on the next "ring" outwards (ring is equal to "1 1 x fontSize")
  5. It'll go in a circle around that ring, using same position algorithm as 2-4. If it's on a further ring, it'll draw a line connecting the text with the point (a "leader line")
  6. Continue through 6 rings
  7. If it can't find a spot, then it'll be hidden

For avoidance, it will avoid:

  • Other marker text

It won't:

  • Take into consideration other annotation text or other shapes. (This can overlap)
  • Expand the axes ranges to "make room" for the annotation

This works very well for the example above.

It doesn't work as well for dense time series. In these cases, I can imagine that we should update the algorithm to focus on showing text for local minima and maxima first, rather than going in the order of the traces. I think we could do so in the future.

image

In that example, performance is about 1s slower than not displaying the text.

The positioning is also done on zooming and panning. Here's a video.
textposition-auto-zoom-pan.webm


Below is written with the use of AI

This PR adds support for textposition: 'auto' on scatter traces, enabling automatic placement of text labels without overlaps. When set to 'auto', labels are positioned around their points in a priority order, with leader lines drawn when labels are moved away from their markers due to space constraints.

  • New module src/traces/scatter/auto_text_position.js: Implements the auto-positioning algorithm using a spatial index to detect overlaps and place labels efficiently. The algorithm:
    • Tries 8 candidate positions around each point in order of preference
    • Adds leader lines when labels must be moved farther out due to nearby markers or other labels
    • Hides labels that have no available space
    • Respects fixed-position labels and per-point textposition arrays

API Updates

  • Updated src/traces/scatter/attributes.js to add 'auto' as a valid textposition value
  • Updated src/traces/scatter3d/attributes.js, src/traces/quiver/attributes.js, src/traces/scattergeo/attributes.js, and src/traces/scattergl/attributes.js to exclude 'auto' from non-2D scatter variants (with comments explaining why)

Drawing & Positioning

  • Modified src/components/drawing/index.js:
    • Added textPointBoxOffset() to compute label geometry from measured bounding boxes
    • Refactored textPointPosition() to support both fixed and auto-positioned labels
    • Added getTextPosition() helper to resolve 'auto' to computed positions
    • Added support for leader lines with configurable gap and clearance

Integration

  • Updated src/traces/scatter/plot.js to call the auto-positioning algorithm after rendering all traces on a subplot
  • Updated src/plot_api/helpers.js to preserve 'auto' in cleanTextPosition()

Tests & Examples

  • Test suite in test/jasmine/tests/scatter_test.js covering:
    • Basic auto positioning with stacked markers
    • Isolated labels taking first candidate position
    • Leader lines in clusters
    • Text-only labels on points
    • Mixed fixed and auto positions
    • Repositioning after zoom/relayout
  • Added test mock test/image/mocks/text_chart_autoposition.json with diverse label scenarios
  • Added real-world example test/image/mocks/text_chart_autoposition_real.json (NIH funding data)

Technical Details

Setup, once per subplot, before any label moves:

Index every marker from every visible scatter trace on the subplot as an obstacle, plus every label that has a fixed (non-auto) position. Collect the auto labels in trace draw order, then data order within a trace.

Then, for each auto label in that order:

  1. Bail if the point is off-plot. If the point's pixel coords fall outside the plot area, hide the label and stop. No candidates are tried.

  2. Build the repulsion vector. Sum (dx/d², dy/d²) over every other point's marker within mrc + 3 × fontSize of this point, where d is the distance and the vector points away from each marker. Nearer markers contribute more. Only markers count, not other labels.

  3. Score and sort all 8 positions once. Each position is a unit direction (top right = (1,-1)/√2, and so on). Score is the dot product with the repulsion vector. Sort descending, ties broken by the default preference order. If the repulsion vector came out zero, skip the sort and use the default order as-is, which starts at top center. This ordering is computed once and reused at every ring.

  4. Ring 0: try each position touching the point, in that order. A candidate wins if its box stays inside the plot area, overlaps no marker, no already-placed label, and no already-placed leader line, and no foreign marker sits within one font size of the box. That last check is the one people miss: a spot can be geometrically free and still get rejected as too ambiguous to read.

  5. No winner at ring 0, step out one font size and run the same list again. Now the label gets a leader line back to the marker edge, and the leader itself has to be clear of obstacles too. Keep stepping out one font size at a time, up to ring 6, re-running the full ordered list at each distance.

  6. Nothing free at any ring: hide the label (display: none).

  7. On a win, insert the box and its leader into the index, so this label becomes an obstacle for every label placed after it.

It's greedy and single-pass. No backtracking, no relaxation, no global optimization.

A point early in the data can permanently push a later point's label into hiding, and reordering your traces changes the output.

Labels are processed in trace and data order, so earlier points win contested positions.

Test Plan

Existing test suite passes. New tests verify:

  • Labels don't overlap with markers or other labels
  • Leader lines are added when labels move away from points
  • Text-only labels can sit on their points
  • Mixed fixed/auto positions work correctly
  • Repositioning works after zoom/relayout operations

Labels on dense scatter plots overlap each other and their markers, and
users place every label by hand or with annotations to work around it.
With `textposition: 'auto'`, each label takes the first free position
next to its point, moves farther out with a leader line when those are
taken, and hides when nothing fits. Placement is a deterministic greedy
pass over a spatial index, so redraws stay fast and repeatable.

The svg scatter family shares the placement through scatter/plot.js.
The gl, geo, 3d and quiver traces keep the old value list, because
their text rendering does not go through that code.

Closes #4674

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A label next to its point reads as ambiguous when other points sit
close by. A point with a neighbor within 1.5 font sizes now skips the
positions next to it and starts at the first leader ring. The ring step
drops from 1.5 to 1 font size, with 6 rings instead of 4, so the shortest
leader is 10px and the reach stays at 6 font sizes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The leader test ignored any obstacle that covered the leader's start.
The start sits at the marker edge, so a neighbor's marker next to the
point was ignored too and the leader ran through it. The exemption now
applies only to obstacles that cover the point itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A point with a neighbor on one side can still take a label on the other
side without doubt about which point owns it. The check now rejects a
position next to the point only when another marker sits within 1.5 font
sizes of the label box at that position.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The padded-box check flagged markers off a label's corner as if they
sat beside it, and 1.5 font sizes of margin put arrows on labels that
read fine without one. The check now measures the distance from the
nearest edge or corner of the label box and uses one font size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The fixed `textposition` geometry stands the text anchor off the marker,
and the anchor is a different part of the text for each position. So
*auto* charts, which mix positions, showed uneven gaps: a side label sat
2px from its marker and a corner label 8px. *auto* labels now place the
measured text box with one clearance from the marker for every position.
Fixed positions keep their geometry.

This is self-contained in `textPointBoxOffset` and its two call sites,
so it can be removed on its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A corner label kept its clearance from the corner of a square marker,
which left circles, the common case, 2px farther than side labels.
Circle symbols now get the circle geometry. Every other symbol keeps
the square geometry, because a square reaches the farthest of all
symbols on the diagonal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The mock now holds mixed symbols, per-point font sizes, multi-line
labels, bubbles, text-only points, fixed positions mixed with *auto*,
points on the plot edges and corners, and a tight cluster.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
After a zoom, a point past the edge of the plot area could still get a
label when one candidate box fit inside the axes, which left the label
with no visible marker, or a leader that pointed off the plot. Such a
point now hides its label. A test zooms into a cluster, then past
some of its points, and back out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Twelve points on a small circle in clockwise order show whether the
labels around a cluster follow the order of their points.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The mock now holds scenarios where a better placement would show:
a clock and a ring for the natural side of a label, a row and a
diagonal chain for consistent sides, an order trap where the first
point in data order takes the only spot of the second, two clusters
for leader crossings, and a blob for how many labels fit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Every label tried the same fixed list of positions, so labels around
a cluster took whichever spot came first in the list instead of the
spot that faces away from the cluster. The candidates are now sorted
by the direction that points away from the markers near the point,
with the fixed order as the tiebreak, so an isolated point places as
before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Sixty-seven labeled conditions on log axes, in three styled traces,
at a size where the placement must use leader lines and hide labels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The placement no longer needs the line count or the fixed-position
offset, which only the drawing module uses now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
5,040 readings at four-minute steps with the value as the label,
where only a few hundred labels can fit and each one needs a leader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
A trace without markers could sit its label on the point, which put
the text on top of the line. Only a trace with neither markers nor
lines uses that position now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
The auto pass runs inside the trace plot step, before annotations,
shapes, images, and the legend draw. State this in the attribute
description so users know to set a fixed position on points that
sit under those elements.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCVbsNcheYqzMQjRnZJ3LB
@camdecoster

Copy link
Copy Markdown
Contributor

This is a great new feature! I have a few comments:

  • I'd like to separate the placement engine from the trace so that it can be reused throughout the library. geometry2d.js seems like a good place to move things. Ideally, the API would be set up such that the algorithm could change under the hood and the traces wouldn't have to be updated.
  • What do you think of allowing for pixel positioning rather than a set of predetermined positions? That could make the fit more flexible/compact.
  • I'd argue that we should include a label importance/priority sort from the beginning. If you use a helper function to do this, we can tweak the logic as needed.
  • What do you think of the label "popping" that occurs when you pan? It looks a little odd, but I don't have a good suggestion at the moment. It's probably fine to start from this point and iterate if necessary.

This should fix issue #4674. Could you add that in the description?

The map layer symbol and the scattermap trace reuse the scatter
`textposition` object, so *auto* leaked into their enum through
`Lib.extendFlat`. Map-gl has no placement pass and drew such labels
at the center without a warning. Filter the value out, as the gl
and geo traces already do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
`textPointPosition` set `display` on the `<text>` node, which undid
the hiding that `convertToTspans` relies on for the tex source. A
selected point then showed the raw tex over the rendered math. Hide
the `g.textpoint` group instead, so the MathJax render goes with it.

A MathJax label renders after the placement pass, so the pass
measured the hidden source text and reserved an empty box. Skip a
pending label and run the pass again once every render is done, as
the axis tick labels do. Measure a rendered label from its math
group, and align the math svg to the anchor the placement picks.

A leader line was drawn from the `x` and `y` attributes of the text
node, which still hold the old values in the middle of a transition.
Keep the leader segment on the calcdata point in plot coordinates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
The scatter *auto* textposition code held both the placement
algorithm and the trace-specific parts: measuring labels, the box
geometry of a position, and applying the result to calcdata. Other
traces could not reuse it, and a change to the algorithm would have
touched the trace code.

`Lib.placeLabels` in `geometry2d.js` now takes marker boxes, fixed
boxes, and labels that supply a `rect(position, gap)` callback, and
returns a position, gap, and leader per label. The scatter code only
gathers the inputs and applies the results. The engine places labels
by priority through a sort helper, so that rule can change in one
place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
A dense trace can show only some of its labels, and the placement
order decided which ones. `textpriority`, per point or per trace,
lets the figure say which labels matter: they take their position
first, so they stay next to their point and are hidden last. It is
coerced only when `textposition` has *auto*, and only the SVG
scatter traces carry it.

The time-series mock marks the extrema of each four-hour window
with priority 1, so the peaks and troughs keep their value labels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
Every redraw placed the labels from scratch, so a pan that moved a
neighbour by a few px could flip a label to the other side of its
point, and one such move could cascade through the labels around it.

A label now names the position it had before, and the engine tries
that one first in every ring. The label still moves when its old
spot is taken, and still comes closer when a nearer ring frees up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
Trying the old position first still let a label step through the
rings on every pan, so its leader line came and went. The label now
goes back next to its point at its old position when it can, else
it keeps its old position and gap, and only then it searches again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yPmc4gXnKedfLbnBZGhva
@chriddyp

Copy link
Copy Markdown
Member Author

TYSM for the review! Addressed some of the concerns.


Zoom & Pan: Feeling pretty good to me now:
zoom text autopositioning


geometry2d.js

✅ Done


What do you think of allowing for pixel positioning rather than a set of predetermined positions? That could make the fit more flexible/compact.

One thing I'm keeping in mind in this design is trying to make the "visual interpretation" of the text feel consistent and easy to navigate.

I think that limiting the available directions to be 1/8 possible ones rather than an arbitrary angle, it can help a bit with this: if a text label is near two points, then users can get a hint that the text that is associated with the point is the one that has a "clean" angle (top, top-left, right, etc) rather than a "random" angle. It's sort of like how graphic design software will provide the "snap-to" mechanism.

So I think I'd like to keep the angle restricted. The distance of the vector could be more arbitrary, but I think there's enough granularity right now.

All to say, I'm pretty happy with the results so far, so I'm not too inclined to change the algorithm. I think doing arbitrary positioning instead of iteratively trying a series of positions would require a pretty different algorithm.


On another note, I found another case of non-ideal placement: The leader lines can cross the actual line chart
image

Fixing this one feels more complex, so I'm OK with punting on it and fixing in the future.


Speaking of fixing in the future, I'm thinking about adding a note to the attribute description that says something like "Auto placement uses an algorithm to find the correct placement. This algorithm is subject to change in future minor and patch versions."

Just so that we feel like we can improve the algorithm in the future without issuing a major version release.


I'd argue that we should include a label importance/priority sort from the beginning.

Done! textpriority.

Here's an example without it, notice the point I'm hovering over and it's neighbor doesn't have text:
image

And here it is with those points:
image

Another example without it (notice that not all peaks have labels):
image

And then with textpriority and the peaks have labels:
image

@camdecoster camdecoster self-assigned this Sep 22, 2026
if (sx && sy) {
var isCircle = r && drawing.symbolNumber(markerSymbol) % 100 === 0;
reach = isCircle ? (r + clearance) / Math.SQRT2 : r + clearance / Math.SQRT2;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quick explanation of this part: In my reference scatter plot, I noticed that the text was closer to the squares than the circles, because the circles' corners are folded in. In dense scatter plots with text, I find myself comparing the text to different points to try to understand which text is associated with which point, and this discrepancy of distance was throwing me off.

Circles are the default case, so that's handled. And then squares are the largest symbol from the corner, so that's the default otherwise for the rest of the symbols.

I recognize this is getting kind of hardcore, and would also be OK removing this logic as well.

@archmoj

archmoj commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Hi @chriddyp
This is so cool.

  1. How about using textrank instead of textpriority? i.e. to match with other parts of the API e.g. legendrank
  2. When zooming into text_chart_autoposition_timeseries and just a few points are visible in the range, the graph still renders/updates slowly. Are the text points outside the visible range computed? If that's the case filtering them should improve the performance.

Comment thread src/lib/geometry2d.js
Comment on lines +237 to +246
const POSITIONS = [
'top center',
'bottom center',
'middle right',
'middle left',
'top right',
'top left',
'bottom right',
'bottom left'
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list should also include the middle center as the first member in the case of no marker e.g. when mode: 'text' so let's get that from a function with the mode as an input argument.
Right now e.g. on the text_chart_autoposition_real mock if you call

Plotly.restyle(gd, 'mode', 'text');

the text positions are not center.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great catch!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@camdecoster Is this case handled now? We also need a test for it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like Chris already handled that. I just added a test.

@chriddyp

chriddyp commented Sep 28, 2026 •

Copy link
Copy Markdown
Member Author

How about using textrank instead of textpriority? i.e. to match with other parts of the API e.g. legendrank

Good call, agreed!

When zooming into text_chart_autoposition_timeseries and just a few points are visible in the range, the graph still renders/updates slowly. Are the text points outside the visible range computed? If that's the case filtering them should improve the performance.

Ah yeah that's a great point as well. Agreed 👍🏼

Many thanks for taking a look @archmoj !

@chriddyp chriddyp left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this against a local build in headless Chromium. Each inline comment has a repro, the numbers I measured on this branch, and a proposed fix that I applied locally and measured again. With all the fixes applied together, the 3 new text_chart_autoposition* mocks place every label exactly as before, so no baseline should move. I did not run the jasmine or image test suites.

Ranked by impact:

  1. text_defaults.js: crash on scattergl, scatter3d, scattergeo and scatterpolargl when an array textposition holds auto.
  2. auto_text_position.js L30: zorder groups, overlaid axes and carpets are placed in separate passes, so their labels overlap each other.
  3. drawing/index.js L1349: MathJax label boxes are cached without the font size.
  4. auto_text_position.js L104: the obstacle box of a fixed corner label is offset from the drawn text (noticeable with large markers).
  5. drawing/index.js L1414: leader lines detach after a selection with selected.marker.size.
  6. helpers.js: cleanData stopped cleaning auto on traces that don't support it.

Checked and fine, so no inline comment:

  • Leader lines during scroll zoom stay on their markers, because setTextPointsScale scales around the point.
  • textPointPosition runs about 3 times per label per redraw. On the timeseries mock, all those calls together take about 10% of a relayout, so removing one pass isn't worth the code.

Generated by Claude Code

Comment on lines +13 to +14
if (textposition === 'auto' || (Lib.isArrayOrTypedArray(textposition) && textposition.indexOf('auto') !== -1)) {
coerce('textrank');

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crash: textrank is coerced on traces that don't have it

This file is shared by scattergl, scatterpolargl, scatter3d, scattergeo and scattermap. Those traces strip auto from textposition.values (scatterpolargl also strips textrank), but items of an arrayOk array aren't checked against values. So an array that holds auto reaches this line, coerce('textrank') finds no attribute, and it throws.

Repro (throws on this branch, plots fine on main):

Plotly.newPlot(gd, [{
    type: 'scattergl', mode: 'text',
    x: [1, 2], y: [1, 2], text: ['a', 'b'],
    textposition: ['auto', 'top center']
}]);
// TypeError: Cannot read properties of undefined (reading 'dflt')

Same error for scatter3d, scattergeo and scatterpolargl, and for Plotly.restyle(gd, 'type', 'scattergl') on a scatter trace with textposition: ['auto', 'auto'].

Proposed fix: add a flag next to noSelect and pass it from the 4 traces that already remove auto from their attributes. scattermap is safe as is, because its textposition has arrayOk: false.

/*
 * opts: object of flags to control features not all text users support
 *   noSelect: caller does not support selected/unselected attribute containers
 *   noAutoPosition: caller has no *auto* textposition, so no textrank
 */
module.exports = function(traceIn, traceOut, layout, coerce, opts) {
    opts = opts || {};

    var textposition = coerce('textposition');
    // An array can hold *auto* even where it is not a valid value, because array items are not validated
    if (
        !opts.noAutoPosition &&
        (textposition === 'auto' || (Lib.isArrayOrTypedArray(textposition) && textposition.indexOf('auto') !== -1))
    ) {
        coerce('textrank');
    }
// scattergl/defaults.js; the same flag in scatter3d, scatterpolargl and scattergeo defaults
handleTextDefaults(traceIn, traceOut, layout, coerce, {
    noAutoPosition: true,
    noFontShadow: true,
    noFontLineposition: true,
    noFontTextcase: true
});

I applied this locally, and the repro plots without error for all 4 trace types and for the restyle. A jasmine case per trace type with an array that holds auto would guard it.

Side note: the "auto, or an array that holds auto" check now lives in 3 places (here, hasAutoTextPosition in auto_text_position.js, and plot.js L531). One exported helper would keep them in sync.


Generated by Claude Code

Comment on lines +30 to +32
module.exports = function autoTextPosition(gd, plotinfo, traceGroups) {
// The slider shares the calcdata with the main plot, so a pass here would replace the main plot's results
if (plotinfo.isRangePlot) return;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Labels in different zorder groups, on overlaid axes, or on carpets don't avoid each other

This runs once per call of the scatter plot method. On a cartesian subplot, plotOne calls that method once per zorder group (scatterlayer, scatterlayer2, ...). Every overlaid subplot (yaxis2.overlaying: 'y') and every carpet gets its own call too. Each pass sees only part of the traces in the plot area, so labels from different passes overlap freely. Dual-y-axis charts are common, so I'd rank this one high.

Repro: 2 traces with 12 points each, B shifted slightly from A, both textposition: 'auto' and marker.size: 12. I counted overlapping screen boxes between one trace's labels and the other trace's markers and labels:

setup label on other trace's marker label on label
same zorder 0 0
zorder: 0 and zorder: 1 16 8
B on yaxis: 'y2', overlaying: 'y' 12 12
scatter + scattercarpet on the same axes 0 6
const x = [...Array(12).keys()], y = x.map(i => i % 3);
Plotly.newPlot(gd, [
    { x, y, mode: 'markers+text', text: x.map(i => 'A' + i), textposition: 'auto', marker: { size: 12 }, zorder: 0 },
    { x: x.map(i => i + 0.15), y: y.map(v => v + 0.12), mode: 'markers+text', text: x.map(i => 'B' + i),
      textposition: 'auto', marker: { size: 12 }, zorder: 1 }
], { width: 600, height: 400, showlegend: false });

Proposed fix: let every pass cover all scatter traces in the same cartesian plot area, and use each trace's own axes for the pixel positions. The last pass sees everything, so the end state is right, and prefer keeps the earlier passes from shuffling labels. Polar, ternary and smith keep today's behavior, because their stand-in axes aren't figure axes.

const Axes = require('../../plots/cartesian/axes');

module.exports = function autoTextPosition(gd, plotinfo, traceGroups) {
    if (plotinfo.isRangePlot) return;

    // Each `plot` call draws one zorder group, one overlaid subplot or one carpet,
    // so place the labels of every scatter trace that shares the plot area
    traceGroups = plotAreaTraceGroups(gd, plotinfo, traceGroups);
    // ...
    traceGroups.each(function (cd) {
        // ...
        const xa = traceAxis(gd, trace.xaxis, plotinfo.xaxis);
        const ya = traceAxis(gd, trace.yaxis, plotinfo.yaxis);
        // ...

// a cartesian axis of the figure, or undefined for the stand-in axes of polar, ternary and smith subplots
function cartesianAxis(gd, ax) {
    return ax && ax._id && Axes.getFromId(gd, ax._id) === ax ? ax : undefined;
}

function mainAxis(gd, ax) {
    return ax.overlaying ? Axes.getFromId(gd, ax.overlaying) : ax;
}

function traceAxis(gd, id, dflt) {
    return (cartesianAxis(gd, dflt) && id && Axes.getFromId(gd, id)) || dflt;
}

// the `g.trace` groups of the scatter traces drawn in the same cartesian plot area
function plotAreaTraceGroups(gd, plotinfo, traceGroups) {
    const xa = cartesianAxis(gd, plotinfo.xaxis);
    const ya = cartesianAxis(gd, plotinfo.yaxis);
    if (!xa || !ya) return traceGroups;
    const mx = mainAxis(gd, xa);
    const my = mainAxis(gd, ya);
    return gd._fullLayout._cartesianlayer.selectAll('g.trace.scatter').filter((cd) => {
        const trace = cd && cd[0] && cd[0].trace;
        if (!trace || !trace.xaxis || !trace.yaxis) return false;
        return (
            mainAxis(gd, Axes.getFromId(gd, trace.xaxis)) === mx &&
            mainAxis(gd, Axes.getFromId(gd, trace.yaxis)) === my
        );
    });
}

With this applied locally, all 4 rows go to 0 / 0. Side-by-side subplots stay independent, and the range slider isn't touched, since it doesn't draw under _cartesianlayer. The 3 new text_chart_autoposition* mocks and auto labels on polar, ternary and smith place every label exactly as before. Cost: one more pass for each extra zorder group or overlay that has scatter traces.

One gap remains: plotinfo.axisLayers still comes from the calling subplot only, so inside tick labels of an overlaid axis aren't obstacles. Merging axisLayers from the main subplot and its overlays would close it.


Generated by Claude Code

const math = d3.select(node.parentNode).select('g.text-math-group');
let bb;
if (math.size()) {
bb = drawing.bBox(math.node());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MathJax label boxes are cached without the font size

drawing.bBox(math.node()) falls back to the default nodeHash. On the g.text-math-group node that is data-unformatted + 'Y' + null + null, so the key has no font. The first size measured for a TeX string is reused for every other label with the same string.

Repro (MathJax v3 loaded):

Plotly.newPlot(gd, [
    { x: [0.5], y: [0.5], mode: 'markers+text', text: ['$\\alpha$'], textposition: 'auto', textfont: { size: 10 } },
    { x: [1, 1.3, 1.6, 1.9, 2.2, 2.5], y: [2, 2.1, 2, 2.1, 2, 2.1], mode: 'markers+text',
      text: Array(6).fill('$\\alpha$'), textposition: 'auto', textfont: { size: 40 } }
], { width: 500, height: 400, xaxis: { range: [0, 3] }, yaxis: { range: [0, 3] }, showlegend: false });

The placement measures the 40px labels as 5.9×4.7 px, the size of the 10px one. The real box is 23.6×18.8 px. On screen, one α gets cut off at the right edge of the plot, one sits on a marker, and the leaders stop short of the glyphs. Per-point textfont.size arrays hit the same thing.

Proposed fix: key the render by its source <text> node, whose style has the font:

if (math.size()) {
    // The default key of the render has no font, so key it by its source `<text>` node, which has the font
    const hash = textPointHash(node);
    bb = drawing.bBox(math.node(), false, hash && hash + '~math');

With this applied locally, the placement measures 23.6×18.8 px, every label clears the markers, and the one with no free spot is hidden.


Generated by Claude Code

Comment on lines +104 to +110
if (pos === 'auto') {
labels.push(label);
} else {
const box = label.rect(pos, 0);
box.owner = d;
fixed.push(box);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The obstacle box of a fixed label isn't where the label is drawn

label.rect(pos, 0) uses textPointBoxOffset, the auto geometry. A fixed label is drawn by textPointOffset, though, which puts a corner label r / 0.8 + 1 out on both axes, while textPointBoxOffset puts it (r / 0.8 + 1) / √2 out for a circle. For corner positions the obstacle therefore sits closer to the marker than the real text. Side positions match.

Obstacle box minus drawn <text> box at font size 14 (x, y offset in px):

marker.size top right bottom left top center, middle right
6 -1.4, +1.9 +1.4, +1.1 ≤ 1.5
12 -2.5, +3.0 +2.5, 0 ≤ 1.5
40 -7.6, +8.1 +7.6, -5.1 ≤ 1.5

At the default marker size that's inside PAD. With larger markers an auto label can land on a fixed one:

Plotly.newPlot(gd, [
    { x: [2], y: [5], mode: 'markers+text', text: ['Fixed label'], textposition: 'top right', marker: { size: 40 } },
    { x: [2.6], y: [5.6], mode: 'markers+text', text: ['auto'], textposition: 'auto' }
], { width: 500, height: 400, margin: { l: 0, r: 0, t: 0, b: 0 },
     xaxis: { range: [0, 10] }, yaxis: { range: [0, 10] }, showlegend: false });

'auto' takes top right and overlaps 'Fixed label' by about 24×4 px. Sweeping the auto point over a 21×25 grid next to the fixed label gave 27 positions with an overlap.

Proposed fix: build the box from the geometry that drew the label. This needs textPointOffset on drawing (like textPointFontSize), plus the box math pulled out of labelRect so both paths share it:

const bb = Drawing.textPointBBox(tx);
const label = {
    // ...
    rect: labelRect(d, trace, x, y, bb)
};

if (pos === 'auto') {
    labels.push(label);
} else {
    // A fixed label is drawn with the fixed-position geometry, so block the box it really covers
    const offset = Drawing.textPointOffset(pos, label.fontSize, d.mrc, svgTextUtils.lineCount(tx));
    fixed.push({ ...boxAt(x, y, offset, bb), owner: d });
}

// ...

function labelRect(d, trace, x, y, bb) {
    const symbol = d.mx || (trace.marker || {}).symbol;

    return (pos, gap) => {
        const offset = Drawing.textPointBoxOffset(pos, d.mrc, symbol, bb, gap);
        const seg = offset.leader;
        return { ...boxAt(x, y, offset, bb), leader: seg ? [x + seg[0], y + seg[1], x + seg[2], y + seg[3]] : null };
    };
}

// the box of a label drawn with `offset` from its point (x, y), in the plot frame
function boxAt(x, y, offset, bb) {
    let x0 = x + offset.dx;
    if (offset.anchor === 'end') x0 -= bb.width;
    else if (offset.anchor === 'middle') x0 -= bb.width / 2;
    const y0 = y + offset.dy + bb.top;
    return { x0, y0, x1: x0 + bb.width, y1: y0 + bb.height };
}

With this applied locally, the obstacle matches the drawn box to 0 px in every cell of the table, the grid sweep finds no overlaps, and the 3 new mocks don't change.


Generated by Claude Code

const hasLeader = first && first.nodeName === 'path' && first.classList.contains('textleader');
if (!d._tpAutoLeader && !hasLeader) return;

var leader = group.selectAll('path.textleader').data(d._tpAutoLeader ? [d._tpAutoLeader] : []);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leader lines detach from their labels after a selection

selectedTextStyle (L1505) calls this with d.mrc2 || d.mrc. For an auto label, textPointBoxOffset then moves the text out to the selected marker radius. But d._tpAutoLeader was fixed in the plot frame for d.mrc, so the leader stays where it was while the text moves.

Repro: 40 random points, marker.size: 8, selected.marker.size: 20, textposition: 'auto', dragmode: 'select', then box-select everything. 7 labels have leaders:

leader end to label leader start to marker edge
before select ≤ 2 px ≤ 1 px
after select, this branch up to 9.5 px up to 5 px, inside the bigger marker
after select, fix below ≤ 2 px ≤ 1 px

Fixed labels already move with mrc2, so moving the auto label is consistent. Only the leader has to follow. Shift the stored leader by the change in the two offsets:

var offset;
var leaderSeg = d._tpAutoLeader;
if (isAuto && textPosition && d._tpAuto !== undefined) {
    var symbol = d.mx || (trace.marker || {}).symbol;
    var bb = drawing.textPointBBox(s);
    offset = drawing.textPointBoxOffset(textPosition, markerRadius, symbol, bb, d._tpAutoGap);

    // The placement set the leader for `d.mrc`, so move its ends with a selected or unselected marker size
    if (leaderSeg && markerRadius !== d.mrc) {
        const placed = drawing.textPointBoxOffset(textPosition, d.mrc, symbol, bb, d._tpAutoGap).leader;
        leaderSeg = leaderSeg.map((v, k) => v - placed[k] + offset.leader[k]);
    }
    // ...
}
// ...
if (!leaderSeg && !hasLeader) return;

var leader = group.selectAll('path.textleader').data(leaderSeg ? [leaderSeg] : []);

Known limit: the placement doesn't run again on select, so a label pushed out by a bigger selected marker can still overlap a neighbor (2 label pairs in the repro). Fixed labels act the same way today. Re-running the placement on every select mousemove would cost too much, so I'd leave that alone.


Generated by Claude Code

Comment thread src/plot_api/helpers.js
// textposition - support partial attributes (ie just 'top')
// and incorrect use of middle / center etc.
function cleanTextPosition(textposition) {
if (textposition === 'auto') return textposition;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanData now keeps auto on every non-bar, non-pie trace

On main, cleanTextPosition('auto') returned middle center for every trace that gets here. Now auto passes through for all of them, including traces that don't accept it. Differences from main:

trace input main this branch
scatter3d 'auto' middle center top center (falls back to the scatter3d default)
scatter3d, scattergl, scattergeo ['auto', 'auto'] ['middle center', 'middle center'] throws (see the text_defaults.js comment)

The scatter3d change is small, but it changes how existing figures render. Proposed fix: keep auto only when the trace type accepts it.

if (!traceIs(trace, 'pie-like') && !traceIs(trace, 'bar-like')) {
    // Keep *auto* only where it is valid, and clean it to *middle center* as before everywhere else
    var tpValues = ((Registry.getModule(trace.type || 'scatter') || {}).attributes || {}).textposition;
    var keepAuto = !!tpValues && (tpValues.values || []).indexOf('auto') !== -1;
    if (Array.isArray(trace.textposition)) {
        for (i = 0; i < trace.textposition.length; i++) {
            trace.textposition[i] = cleanTextPosition(trace.textposition[i], keepAuto);
        }
    } else if (trace.textposition) {
        trace.textposition = cleanTextPosition(trace.textposition, keepAuto);
    }
}

// ...

function cleanTextPosition(textposition, keepAuto) {
    if (textposition === 'auto' && keepAuto) return textposition;

With this and the text_defaults.js fix applied locally, scatter3d, scattergl and scattergeo give the same textposition as main for both inputs, and scatter, scatterpolar, scatterternary, scattersmith and scattercarpet keep auto.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

textposition: "avoid overlap"

4 participants